iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
生成式 AI

打造基於 MCP 協議與 n8n 工作流的會議處理 Agent系列 第 18

Day 18 整合驗證 — 智慧指派與準確率測試

  • 分享至 

  • xImage
  •  

經過前幾日的架構重構與功能精煉,我的 AI 會議助理 M2A Agent 已經從一個概念雛形,逐漸蛻變為一個結構清晰、職責分明的智慧系統,今天的目標是要對 M2A Agent 進行多場景的測試

今天的目標與挑戰

  • 建立多場景測試案例
  • 改善會議摘要準確度和任務指派等邏輯
  • 驗證並微調,確保摘要、參與者、任務、指派、期限等項目的準確率達到 90% 以上

Step 1:再進化「會議摘要與資訊提取」節點

為了確保它能穩定地輸出高品質、格式標準的摘要,我對其程式碼進行了強化。

1-1 強化程式

在「會議摘要與資訊提取」節點撰寫以下程式

// 從上游節點取得 Webhook 資料和專案清單
const webhookData = $('Webhook').first().json.body;
const projectListData = $('格式化專案列表').first().json;

const sessionId = webhookData.session_id;
const text = webhookData.text;
const instruction = webhookData.instruction;
const projectList = projectListData.project_list;

// 建立會議屬性分析的 AI Prompt
function buildMeetingAttributePrompt(projectList) {
  const projectListFormatted = Array.isArray(projectList) ? projectList.join('\n- ') : projectList;
  
  return `你是專業的會議屬性分析專家,專門負責從會議逐字稿中提取基本會議資訊。

【已知專案清單】
- ${projectListFormatted}

【你的唯一任務】
1. 判斷會議主要討論的專案(從清單中選擇或回傳 null)
2. 判斷會議類型(7種固定類型之一)
3. 生成簡潔的純文字會議摘要

【會議類型限制 - 必須從以下選擇】
專案討論、技術討論、需求分析、進度報告、創意發想、決策會議、一般會議

【摘要撰寫要求】
summary 必須是**純文字段落摘要**,符合以下規則:
- 只使用自然的句子和段落
- 不要使用任何 Markdown 格式(不要 # ## ### - * • 等符號)
- 不要包含「專案名稱:」「會議類型:」「參與者:」等標籤
- 不要列出參與者名單或任務清單
- 重點描述會議的核心討論內容和主要決議
- 長度控制在 100-200 字之間
- 使用流暢的敘述性語言

【正確摘要範例】
「會議主要討論了員工績效管理系統的開發進展,後端核心架構已初步完成並透過壓力測試。接下來將重點轉向前端的視覺化與使用者互動設計,包括個人績效儀表板和部門績效比較分析模組的開發。智哥負責前端任務安排,團隊決定在三天內完成個人績效儀表板的核心功能並部署到測試環境。」

【錯誤摘要範例 - 避免這樣寫】
❌ 「# 會議摘要\n專案:XX\n參與者:\n- 人員A\n- 人員B\n決議事項:\n1. 任務A\n2. 任務B」

【輸出格式要求】
嚴格按照以下 JSON 格式:
{
  "project_name": "專案名稱或null",
  "meeting_type": "會議類型",
  "summary": "簡潔的純文字摘要(不含任何格式化符號)"
}

【重要】:
- 只輸出純 JSON 格式
- 不可包含任何其他內容
- 專案名稱必須完全匹配清單中的名稱
- 摘要必須是純文字,不含格式化符號`;
}

// 呼叫 AI 進行會議屬性分析
async function callMeetingAttributeAnalysis(text, instruction, projectList, sessionId) {
  // 設定 AI 模型參數,降低溫度值提高穩定性
  const payload = {
    model: "qwen2.5-taiwan-7b-instruct-i1@q5_k_m",
    messages: [
      {
        role: "system",
        content: buildMeetingAttributePrompt(projectList)
      },
      {
        role: "user",
        content: `【會議逐字稿】\n${text}\n\n【額外指令】\n${instruction || '請按照標準流程分析會議'}`
      }
    ],
    temperature: 0.1,
    top_p: 0.9,
    max_tokens: 1024,
    presence_penalty: 0.1,
    frequency_penalty: 0.1
  };

  try {
    // 發送請求到本地 AI 伺服器
    const response = await this.helpers.httpRequest({
      method: 'POST',
      url: 'http://192.168.160.62:1234/v1/chat/completions',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      timeout: 120000
    });

    const responseData = typeof response === 'string' ? JSON.parse(response) : response;
    
    if (responseData.choices && responseData.choices[0] && responseData.choices[0].message) {
      const content = responseData.choices[0].message.content;
      
      // 提取回應中的 JSON 部分
      const match = content.match(/\{[\s\S]*\}/);
      if (match) {
        const parsedResult = JSON.parse(match[0]);
        
        // 檢查並清理摘要中的格式化符號
        const summary = parsedResult.summary;
        if (summary && (summary.includes('#') || summary.includes('- ') || summary.includes('• '))) {
          parsedResult.summary = summary
            .replace(/^#{1,6}\s+/gm, '')
            .replace(/^\s*[-•*]\s+/gm, '')
            .replace(/\*\*(.*?)\*\*/g, '$1')
            .replace(/\*(.*?)\*/g, '$1')
            .replace(/\n+/g, ' ')
            .trim();
        }
        
        return parsedResult;
      }
    }
    
    throw new Error('無法解析 AI 回應');
    
  } catch (error) {
    // 當 AI 分析失敗時,回傳預設值
    return {
      project_name: null,
      meeting_type: "一般會議",
      summary: "AI 分析失敗,無法提取會議屬性。建議檢查會議內容是否完整或聯繫技術支援。"
    };
  }
}

// 執行會議屬性分析
const meetingAttributes = await callMeetingAttributeAnalysis(text, instruction, projectList, sessionId);

// 回傳分析結果
return [{
  json: {
    session_id: sessionId,
    meeting_attributes: meetingAttributes,
    original_text: text,
    instruction: instruction
  }
}];

1-2 強化重點

我的強化重點大致可以分為以下幾點

  1. 極致的 Prompt Engineering:我為 AI 建立了極其嚴格的規則,特別是針對 summary 欄位,要求它必須是「純文字段落」,禁止任何 Markdown 格式。這能確保後續節點接收到的資料是乾淨且標準的。
  2. 降低 Temperature 參數:在 callMeetingAttributeAnalysis 函式中,我將 AI 的 temperature 設為 0.1,犧牲些許創意,換取結果的穩定性與可預測性。
  3. 後處理防禦機制:即使 Prompt 已經很嚴格了,但我仍然在程式中加入了一道防線,在解析 AI 回應後,會再次檢查 summary 是否意外包含了 Markdown 符號,並進行清理,這個「雙重保險」確保了資料的品質。

Step 2:「獲取參與者」節點

這個節點是任務鏈的第二環,專門負責從會議逐字稿中精準識別參與成員,並將其配對到對應的 Notion User ID。

2-1 強化程式

在「獲取參與者」節點撰寫以下程式

// 從上游節點取得會議資料和成員清單
const meetingData = $('會議摘要與資訊提取').first().json;
const memberListData = $('格式化成員列表').first().json;

const sessionId = meetingData.session_id;
const text = meetingData.original_text;
const memberList = memberListData.member_list;
const memberData = memberListData.member_data;

// 建立參與者識別的 AI 提示詞
function buildParticipantIdentificationPrompt(memberList) {
  const memberListFormatted = Array.isArray(memberList) ? memberList.join('\n- ') : memberList;
  
  return `你是專業的參與者識別專家,專門從會議逐字稿中準確識別參與成員。

【已知成員清單】
- ${memberListFormatted}

【識別策略】
1. **直接發言標記**:「XX說」、「XX表示」、「XX提到」
2. **角色稱呼**:職位、暱稱、簡稱
3. **對話指向**:「你」、「我」、對話中的角色
4. **工作指派**:被分配任務的成員
5. **通用表達判斷**:
   - 「各位」、「大家」、「全體」→ 包含所有成員
   - 「在場的」、「參與的」→ 需要具體識別

【重要原則】
- 只有在逐字稿中有明確證據的成員才能被識別為參與者
- 不要因為「各位」就自動包含所有成員,除非有其他證據支持
- 謹慎處理暱稱和簡稱,確保映射正確

【輸出格式】
{
  "participants": ["實際參與者姓名列表"],
  "evidence": ["識別證據列表"]
}`;
}

// 呼叫 AI 進行參與者識別分析
async function identifyMeetingParticipants(text, memberList) {
  const payload = {
    model: "qwen2.5-taiwan-7b-instruct-i1@q5_k_m",
    messages: [
      {
        role: "system",
        content: buildParticipantIdentificationPrompt(memberList)
      },
      {
        role: "user",
        content: `【會議逐字稿】\n${text}\n\n請準確識別參與者。`
      }
    ],
    temperature: 0.2,
    max_tokens: 1024
  };

  try {
    // 發送請求到本地 AI 伺服器
    const response = await this.helpers.httpRequest({
      method: 'POST',
      url: 'http://192.168.160.62:1234/v1/chat/completions',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload)
    });

    const responseData = typeof response === 'string' ? JSON.parse(response) : response;
    const content = responseData.choices[0].message.content;
    
    // 提取回應中的 JSON 部分
    const match = content.match(/\{[\s\S]*\}/);
    if (match) {
      return JSON.parse(match[0]);
    }
    
    throw new Error('無法解析參與者資訊');
    
  } catch (error) {
    // 當 AI 識別失敗時,回傳空的參與者清單
    return {
      participants: [],
      evidence: ["AI 識別失敗"]
    };
  }
}

// 將參與者姓名對應到 Notion User ID
function matchParticipantsToNotionIds(participants, memberData) {
  const participantIds = [];
  
  participants.forEach(participantName => {
    // 尋找匹配的成員,支援正名和暱稱比對
    const matchedMember = memberData.find(member => {
      return member.name === participantName || 
             (member.nicknames && member.nicknames.includes(participantName));
    });
    
    // 如果找到匹配成員且有 Notion ID,加入清單
    if (matchedMember && matchedMember.notion_user_id) {
      participantIds.push(matchedMember.notion_user_id);
    }
  });
  
  return participantIds;
}

// 執行參與者識別與 ID 對應
const participantInfo = await identifyMeetingParticipants(text, memberList);
const participantIds = matchParticipantsToNotionIds(participantInfo.participants, memberData);

// 回傳識別結果
return [{
  json: {
    session_id: sessionId,
    meeting_attributes: meetingData.meeting_attributes,
    participant_info: {
      participants: participantInfo.participants,
      participant_ids: participantIds,
      evidence: participantInfo.evidence
    },
    original_text: text
  }
}];

2-2 強化重點

我的強化重點大致可以分為以下幾點

  1. 證據導向的辨識策略:我為 AI 設計了五層辨識策略,從最直接的發言標記到複雜的通用表達判斷,確保每一位參與者的識別都有紮實的證據證明,這避免了過去「一刀切」將所有成員都標記為參與者的問題。
  2. 精準配對函式matchParticipantsToNotionIds 函式是這個節點的核心,它不僅支援正名配對,還能處理暱稱配對,大幅提升了配對成功率。這個函式會逐一檢查每位參與者,確保只有真正被辨識出來的成員才會被納入最終清單。
  3. 容錯機制設計:即使 AI 識別失敗,系統也不會崩潰,而是回傳一個空的參與者清單與錯誤證據,讓後續節點能夠繼續運作,保持整體流程的穩定性。

Step 3:「提取結構化任務」節點

這是 AI 任務鏈的最後一環,也是最複雜的節點,它負責任務分析、智慧指派與所有資訊的最終整合。

3-1 強化程式

在「提取結構化任務」節點撰寫以下程式

// ===== 1. 從前面節點獲取資料 =====
const previousData = $('獲取參與者').first().json;
const memberListData = $('格式化成員列表').first().json;

const sessionId = previousData.session_id;
const text = previousData.original_text;
const participants = previousData.participant_info.participants;
const memberData = memberListData.member_data;

console.log(`[${sessionId}] 開始提取結構化任務,參與者:${participants.join('、')}`);

// ===== 2. 字串清理函式 =====
function sanitizeJsonString(str, preserveNewlines = false) {
  if (!str || typeof str !== 'string') {
    return str;
  }
  
  if (preserveNewlines) {
    return str
      .replace(/\r\n/g, '\n')
      .replace(/\r/g, '\n')
      .replace(/\t/g, ' ')
      .replace(/[\u0000-\u001f]/g, (char) => char === '\n' ? '\n' : '')
      .replace(/\\/g, '\\\\')
      .replace(/"/g, '\\"')
      .trim();
  }
  
  return str
    .replace(/\r\n/g, ' ')
    .replace(/\r/g, ' ')
    .replace(/\n/g, ' ')
    .replace(/\t/g, ' ')
    .replace(/[\r\n\t]/g, ' ')
    .replace(/[\u0000-\u001f]/g, '')
    .replace(/\\/g, '\\\\')
    .replace(/"/g, '\\"')
    .trim();
}

// ===== 2.1詳細的摘要清理函式 =====
function cleanSummaryFormat(summary) {
  if (!summary || typeof summary !== 'string') {
    return summary;
  }
  
  return summary
    .replace(/^#{1,6}\s+/gm, '')
    .replace(/^\s*[-•*]\s+/gm, '')
    .replace(/\*\*(.*?)\*\*/g, '$1')
    .replace(/\*(.*?)\*/g, '$1')
    .replace(/__(.*?)__/g, '$1')
    .replace(/_(.*?)_/g, '$1')
    .replace(/\n{3,}/g, '\n\n')
    .replace(/^[ \t]+/gm, '')
    .replace(/[ \t]+$/gm, '')
    .trim();
}

// ===== 2.2 日期計算函式=====
function calculateDeadlineFromExpression(timeExpression) {
  const today = new Date();
  const todayStr = today.toISOString().split('T')[0];
  
  if (!timeExpression || timeExpression === 'null') {
    const tomorrow = new Date(today);
    tomorrow.setDate(tomorrow.getDate() + 1);
    return tomorrow.toISOString().split('T')[0];
  }
  
  const expr = timeExpression.toLowerCase();
  
  // 「X天後」或「X天內」
  const daysMatch = expr.match(/(\d+|[一二三四五六七八九十]+)天/);
  if (daysMatch) {
    const dayMap = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10};
    let days = parseInt(daysMatch[1]) || dayMap[daysMatch[1]] || 1;
    const result = new Date(today);
    result.setDate(result.getDate() + days);
    return result.toISOString().split('T')[0];
  }
  
  // 「今天」
  if (expr.includes('今天') || expr.includes('今日')) {
    return todayStr;
  }
  
  // 「明天」
  if (expr.includes('明天') || expr.includes('明日')) {
    const result = new Date(today);
    result.setDate(result.getDate() + 1);
    return result.toISOString().split('T')[0];
  }
  
  // 「後天」
  if (expr.includes('後天')) {
    const result = new Date(today);
    result.setDate(result.getDate() + 2);
    return result.toISOString().split('T')[0];
  }
  
  // 「X週後」或「X周後」
  const weeksMatch = expr.match(/([一二兩三1-9])週|([一二兩三1-9])周/);
  if (weeksMatch) {
    const weekMap = {'一':1,'二':2,'兩':2,'三':3};
    let weeks = parseInt(weeksMatch[1] || weeksMatch[2]) || weekMap[weeksMatch[1] || weeksMatch[2]] || 1;
    const result = new Date(today);
    result.setDate(result.getDate() + (weeks * 7));
    
    // 如果有「星期X」的指定
    const dayMatch = expr.match(/星期([一二三四五六日天])|禮拜([一二三四五六日天])|週([一二三四五六日天])/);
    if (dayMatch) {
      const dayMap = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'日':0,'天':0};
      const targetDay = dayMap[dayMatch[1] || dayMatch[2] || dayMatch[3]];
      const currentDay = result.getDay();
      let daysToAdd = targetDay - currentDay;
      if (daysToAdd < 0) daysToAdd += 7;
      result.setDate(result.getDate() + daysToAdd);
    }
    
    return result.toISOString().split('T')[0];
  }
  
  // 「下週X」或「下週」
  if (expr.includes('下週') || expr.includes('下周') || expr.includes('下星期') || expr.includes('下禮拜')) {
    const result = new Date(today);
    result.setDate(result.getDate() + 7);
    
    const dayMatch = expr.match(/星期([一二三四五六日天])|禮拜([一二三四五六日天])|週([一二三四五六日天])/);
    if (dayMatch) {
      const dayMap = {'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'日':0,'天':0};
      const targetDay = dayMap[dayMatch[1] || dayMatch[2] || dayMatch[3]];
      const currentDay = result.getDay();
      let daysToAdd = targetDay - currentDay;
      if (daysToAdd <= 0) daysToAdd += 7;
      result.setDate(result.getDate() + daysToAdd);
    }
    
    return result.toISOString().split('T')[0];
  }
  
  // 「月底」
  if (expr.includes('月底')) {
    const result = new Date(today.getFullYear(), today.getMonth() + 1, 0);
    return result.toISOString().split('T')[0];
  }
  
  // 預設:明天
  const tomorrow = new Date(today);
  tomorrow.setDate(tomorrow.getDate() + 1);
  return tomorrow.toISOString().split('T')[0];
}

// ===== 3. 專用任務提取 Prompt(只要求辨識時間表達)=====
function buildTaskExtractionPrompt(participants) {
  const participantList = participants.join('、');
  
  return `你是專業的任務提取專家,專門從會議內容中識別具體的行動項目和責任分配。

【會議參與者】
${participantList}

【任務識別規則】
1. **明確指派**:「XX,請你負責...」「XX來處理...」
2. **承諾性陳述**:「我會負責...」「我來處理...」
3. **帶期限的工作**:包含時間要求的具體任務
4. **可執行行動**:有明確產出的工作項目

【任務分類標準】
- 技術:開發、程式、修復、架構、部署相關
- 設計:UI/UX、視覺、原型、介面相關
- 管理:協調、規劃、追蹤、溝通相關
- 測試:驗證、品質、檢查相關
- 其他:不屬於以上類別的任務

【時間表達識別 - 重要】
請從任務描述中提取**原始的時間表達式**,不需要計算日期。
例如:「三天後」、「下週三」、「兩週後的星期一」、「月底前」

【輸出格式】
{
  "tasks": [
    {
      "title": "簡潔任務標題",
      "description": "詳細任務描述",
      "assignee": "負責人姓名或null",
      "category": "任務分類",
      "priority": "高/中/低",
      "time_expression": "原始時間表達式(如:三天後、下週三)"
    }
  ]
}`;
}

// ===== 4. 任務提取函式 =====
async function extractStructuredTasks(text, participants) {
  const payload = {
    model: "qwen2.5-taiwan-7b-instruct-i1@q5_k_m",
    messages: [
      {
        role: "system",
        content: buildTaskExtractionPrompt(participants)
      },
      {
        role: "user",
        content: `【會議逐字稿】\n${text}\n\n請提取所有具體任務。`
      }
    ],
    temperature: 0.3,
    max_tokens: 2048
  };

  try {
    const response = await this.helpers.httpRequest({
      method: 'POST',
      url: 'http://100.96.81.13:1234/v1/chat/completions',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      timeout: 60000
    });

    const responseData = typeof response === 'string' ? JSON.parse(response) : response;
    const content = responseData.choices[0].message.content;
    
    console.log(`[${sessionId}] AI 任務提取回應:`, content);
    
    const match = content.match(/\{[\s\S]*\}/);
    if (match) {
      return JSON.parse(match[0]);
    }
    
    throw new Error('無法解析任務資訊');
    
  } catch (error) {
    console.error(`[${sessionId}] 任務提取失敗:`, error);
    return { tasks: [] };
  }
}

// ===== 5. 智慧任務指派函式 =====
function enhanceTaskAssignment(tasks, participants, memberData) {
  const nameToIdMap = {};
  memberData.forEach(member => {
    nameToIdMap[member.name] = member.notion_user_id;
    member.nicknames.forEach(nickname => {
      nameToIdMap[nickname] = member.notion_user_id;
    });
  });
  
  console.log(`[${sessionId}] 姓名到 ID 對應表:`, nameToIdMap);
  
  return tasks.map(task => {
    let assignee = task.assignee;
    let assigneeId = null;
    
    if (assignee && assignee !== 'null') {
      assigneeId = nameToIdMap[assignee];
      if (!assigneeId) {
        console.warn(`[${sessionId}] 找不到 ${assignee} 的對應 ID`);
      }
    }
    
    if (!assignee || assignee === 'null') {
      if (participants.length > 0) {
        const randomIndex = Math.floor(Math.random() * participants.length);
        assignee = participants[randomIndex];
        assigneeId = nameToIdMap[assignee];
      }
    }
    
    // 使用程式碼計算日期
    const calculatedDate = calculateDeadlineFromExpression(task.time_expression);
    
    return {
      ...task,
      assigned_to: assignee,
      assigned_to_id: assigneeId,
      assignment_method: assignee === task.assignee ? '明確指派' : '智能分配',
      calculated_deadline: calculatedDate
    };
  });
}

// ===== 6. 任務格式化函式 =====
function formatTaskListForDisplay(tasks) {
  if (!Array.isArray(tasks) || tasks.length === 0) {
    return '暫無行動任務';
  }

  return tasks.map((task, idx) => {
    const taskLines = [
      `任務 ${idx + 1}`,
      `• 負責人:${task.assigned_to || task.assignee || '待分配'}`,
      `• 內容:${task.description}`,
      `• 期限:${task.calculated_deadline || task.time_expression}`
    ];
    return taskLines.join('\n');
  }).join('\n\n');
}

// ===== 7. AI 整合摘要函式 =====
async function generateIntelligentSummary(sessionId, meetingAttributes, participantInfo, taskInfo) {
  const today = new Date();
  const todayStr = today.toISOString().split('T')[0];
  const dayOfWeek = ['日','一','二','三','四','五','六'][today.getDay()];
  const tomorrow = new Date(today);
  tomorrow.setDate(tomorrow.getDate() + 1);
  const tomorrowStr = tomorrow.toISOString().split('T')[0];
  
  // 從任務中找出最晚的期限
  let primaryDeadline = tomorrowStr;
  let primaryTimeExpr = '未指定';
  
  if (taskInfo?.tasks && taskInfo.tasks.length > 0) {
    const validTasks = taskInfo.tasks.filter(t => t.calculated_deadline);
    if (validTasks.length > 0) {
      validTasks.sort((a, b) => new Date(b.calculated_deadline) - new Date(a.calculated_deadline));
      primaryDeadline = validTasks[0].calculated_deadline;
      primaryTimeExpr = validTasks[0].time_expression || '未指定';
    }
  }
  
  const payload = {
    model: "qwen2.5-taiwan-7b-instruct-i1@q5_k_m",
    messages: [
      {
        role: "system",
        content: `你是專業的會議資訊整合專家,負責將分散的會議分析結果整合為完整的會議記錄。

【你的任務】
根據提供的會議分析結果,智能地整合並生成完整的會議資訊:

1. **會議摘要強化**:基於原有摘要,結合參與者和任務資訊,生成更完整的會議摘要
2. **任務整合格式化**:將結構化任務轉換為指定的格式
3. **資訊一致性檢查**:確保參與者、任務指派等資訊的一致性

【任務格式化要求】
formatted_tasks 欄位必須嚴格按照以下模板格式化每個任務:

任務 1
• 負責人:[負責人姓名]
• 內容:[任務詳細描述]
• 期限:[期限資訊]

任務 2
• 負責人:[負責人姓名]  
• 內容:[任務詳細描述]
• 期限:[期限資訊]

【輸出格式要求】
{
  "enhanced_summary": "強化版的會議摘要",
  "formatted_tasks": "格式化的任務清單", 
  "task_statistics": {
    "total_tasks": 數字,
    "high_priority_tasks": 數字,
    "assigned_tasks": 數字
  },
  "consistency_notes": "資訊一致性檢查結果或建議"
}`
      },
      {
        role: "user",
        content: `請智能整合以下會議分析結果:

【會議基本資訊】
專案名稱: ${meetingAttributes?.project_name || 'null'}
會議類型: ${meetingAttributes?.meeting_type || '未知'}
原始摘要: ${meetingAttributes?.summary || '無摘要'}

【參與者資訊】  
參與者: ${participantInfo?.participants?.join('、') || '無參與者'}

【任務資訊】
${JSON.stringify(taskInfo?.tasks || [], null, 2)}

請進行智能整合並按 JSON 格式輸出。`
      }
    ],
    temperature: 0.2,
    max_tokens: 2048
  };

  try {
    const response = await this.helpers.httpRequest({
      method: 'POST',
      url: 'http://100.96.81.13:1234/v1/chat/completions',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      timeout: 120000
    });

    const responseData = typeof response === 'string' ? JSON.parse(response) : response;
    
    if (responseData.choices && responseData.choices[0] && responseData.choices[0].message) {
      const content = responseData.choices[0].message.content;
      console.log(`[${sessionId}] AI 整合摘要原始回應:`, content);
      
      const match = content.match(/\{[\s\S]*\}/);
      if (match) {
        const aiResult = JSON.parse(match[0]);
        
        if (aiResult.enhanced_summary) {
          aiResult.enhanced_summary = cleanSummaryFormat(aiResult.enhanced_summary);
        }
        
        console.log(`[${sessionId}] 智能整合完成,主要期限: ${primaryDeadline}`);
        
        // 加入程式計算的期限資訊
        aiResult.primary_deadline_date = primaryDeadline;
        aiResult.primary_time_expression = primaryTimeExpr;
        
        return aiResult;
      }
    }
    
    throw new Error('無法解析 AI 整合結果');
    
  } catch (error) {
    console.error(`[${sessionId}] 智能整合失敗:`, error);
    
    return {
      enhanced_summary: meetingAttributes?.summary || "AI 整合失敗,使用原始摘要",
      formatted_tasks: formatTaskListForDisplay(taskInfo?.tasks || []),
      primary_deadline_date: primaryDeadline,
      primary_time_expression: primaryTimeExpr,
      task_statistics: {
        total_tasks: (taskInfo?.tasks || []).length,
        high_priority_tasks: 0,
        assigned_tasks: (taskInfo?.tasks || []).filter(t => t.assigned_to).length
      },
      consistency_notes: "AI 整合失敗,建議手動檢查資料一致性"
    };
  }
}

// ===== 8. 執行任務提取 =====
const taskResult = await extractStructuredTasks(text, participants);
const enhancedTasks = enhanceTaskAssignment(taskResult.tasks, participants, memberData);

console.log(`[${sessionId}] 任務提取完成,共 ${enhancedTasks.length} 項任務`);

// ===== 9. 執行 AI 智慧整合 =====
const aiIntegrationResult = await generateIntelligentSummary(
  sessionId, 
  previousData.meeting_attributes,
  previousData.participant_info,
  { tasks: enhancedTasks }
);

console.log(`[${sessionId}] AI 智慧整合完成`);

// ===== 10. 收集被指派人員 ID =====
const assignedPersonIds = enhancedTasks
  .filter(task => task.assigned_to_id)
  .map(task => task.assigned_to_id);
  
const uniqueAssignedPersonIds = [...new Set(assignedPersonIds)];

// ===== 11. 格式化任務顯示並清理字元 =====
const formattedTasksDisplay = aiIntegrationResult.formatted_tasks || formatTaskListForDisplay(enhancedTasks);
const safeFormattedTasks = sanitizeJsonString(formattedTasksDisplay, true);

// ===== 12. 回傳完整結果 =====
return [{
  json: {
    session_id: sessionId,
    meeting_attributes: {
      ...previousData.meeting_attributes,
      project_id: previousData.meeting_attributes?.project_id
    },
    participant_info: previousData.participant_info,
    task_info: {
      tasks: enhancedTasks,
      task_count: enhancedTasks.length
    },
    meeting_info: {
      project_name: previousData.meeting_attributes?.project_name,
      project_id: previousData.meeting_attributes?.project_id,
      meeting_type: previousData.meeting_attributes?.meeting_type,
      participants: previousData.participant_info?.participants || [],
      participant_persons: previousData.participant_info?.participant_ids || [],
      assigned_persons: uniqueAssignedPersonIds,
      summary: aiIntegrationResult.enhanced_summary,
      tasks: safeFormattedTasks,
      deadline_date: aiIntegrationResult.primary_deadline_date,
      original_time_expression: aiIntegrationResult.primary_time_expression,
      structured_tasks: JSON.stringify(enhancedTasks),
      task_statistics: aiIntegrationResult.task_statistics,
      consistency_notes: aiIntegrationResult.consistency_notes
    },
    original_text: text,
    ai_integration_result: aiIntegrationResult
  }
}];

3-2 強化重點

我的強化重點大致可以分為以下幾點

  1. AI 驅動的多層次任務辨別
    透過為 AI 設計的四層辨識規則(明確指派、承諾性陳述、帶期限的工作、可執行行動)與五大分類標準,系統能自動化地從會議中精準捕捉並歸類所有具體任務。
  2. 智慧任務指派系統
    enhanceTaskAssignment 函式能將辨識出的負責人姓名(含綽號)精準轉換為 Notion ID。當任務無人負責時,系統會從與會者中智能分配,並標記分配方式,確保每個任務都有明確歸屬與可追溯性。
  3. 程式碼與 AI 協作的整合專家
    採用混合模式,透過 calculateDeadlineFromExpression 函式以程式碼精準解析中文時間表達(如「下週五」),而 generateIntelligentSummary 函式則扮演 AI 整合大腦,將包含準確期限的結構化數據,生成連貫的會議摘要與任務清單。
  4. 端到端的強健容錯機制
    系統具備全面的穩定性設計。從前端的字串清理,到核心 API 呼叫的 try...catch 錯誤處理,再到 AI 整合失敗時的備用回傳機制,確保了工作流程在任何異常情況下都能穩定運行不中斷。

Step 4:重構工作流程連接與相關節點

完成三個 AI 任務鏈節點後,需要調整整體的工作流程。

4-1 更新「格式化成員列表」節點

// 從上一個節點「載入成員列表」獲取所有成員資料
const membersData = $input.all();

// 輔助函式,使用多種分隔符號分割字串
function splitByMultipleDelimiters(text) {
  if (!text) return [];
  return text.split(/[、,;,]/).map(item => item.trim()).filter(item => item);
}

// 處理每個成員資料,建立標準化的成員資訊陣列
const memberList = membersData.map(member => {
  const memberData = member.json;

  // 提取成員的各項屬性資料
  const name = memberData.properties?.姓名?.title?.[0]?.plain_text || '未知姓名';
  const email = memberData.properties?.電子郵件?.email || '';
  const nicknameText = memberData.properties?.暱稱?.rich_text?.[0]?.plain_text || '';
  const positionText = memberData.properties?.職位?.rich_text?.[0]?.plain_text || '';
  const department = memberData.properties?.部門?.select?.name || '';
  const notionUserField = memberData.properties?.['Notion 成員']?.people?.[0];
  const status = memberData.properties?.狀態?.select?.name || '啟用';
  
  // 過濾掉不符合條件的成員
  // 必須有 Notion User 綁定、狀態為啟用、且有有效姓名
  if (!notionUserField?.id || status !== '啟用' || name === '未知姓名') {
    return null;
  }

  // 分割暱稱和職位字串成陣列
  const nicknames = splitByMultipleDelimiters(nicknameText);
  const positions = splitByMultipleDelimiters(positionText);

  // 建立所有可用於辨識的關鍵字清單
  const allKeywords = [
    name,
    ...nicknames,
    ...positions,
    department,
    email ? email.split('@')[0] : ''
  ].filter(k => k && k.trim() !== '');

  // 回傳標準化的成員物件
  return {
    id: memberData.id,
    name: name,
    nicknames: nicknames,
    positions: positions,
    department: department,
    email: email,
    notion_user_id: notionUserField.id,
    status: status,
    keywords: [...new Set(allKeywords)]
  };
}).filter(member => member !== null);

// 建立給 AI 參考用的成員清單字串
const memberListString = memberList.map(member => {
  let memberInfo = `${member.name}`;
  if (member.nicknames.length > 0) {
    memberInfo += ` (別名: ${member.nicknames.join('、')})`;
  }
  if (member.positions.length > 0) {
    memberInfo += ` [職位: ${member.positions.join('、')}]`;
  }
  return memberInfo;
}).join('\n');

// 回傳處理完成的成員資料
return [{
  json: {
    member_list: memberListString,
    member_data: memberList,
    total_members: memberList.length
  }
}];

4-2 修改 Merge 節點設定

  1. 開啟兩個「Merge」節點
  2. 將兩個「Merge」節點的 Input 數量從 2 改為 3
  3. 新增第三個輸入來源:「成員 ID 處理」節點

4-3 修改「If」節點的表達式

  1. 開啟「If」節點
  2. 修改條件判斷表達式 {{ $json.meeting_attributes.project_name }} ,確保能正確讀取來自新任務鏈的資料

4-4 更新「寫入會議紀錄」節點

由於資料結構有所變化,因此需要更新兩個「寫入會議紀錄」節點的表達式

  1. True 分支(有專案關聯)

    // 會議摘要
    {{ $json.meeting_attributes.summary }}
    
    // 行動任務
    {{ $json.meeting_info.tasks }}
    
    // 會議類型
    {{ $json.meeting_attributes.meeting_type }}
    
    // Session ID
    {{ $json.session_id }}
    
    // 完成期限
    {{ $json.meeting_info.deadline_date }}
    
    // 原始時間描述
    {{ $json.meeting_info.original_time_expression }}
    
    // 參與夥伴
    {{ $('成員 ID 處理').first().json.meeting_info.participant_persons }}
    
    // 任務指派
    {{ $('成員 ID 處理').first().json.meeting_info.assigned_persons }}
    
  2. False 分支(無專案關聯)
    複製剛剛改好的「寫入會議紀錄」節點,只是移除專案關聯。

4-5 修改「Markdown 格式化處理」節點

更新「Markdown 格式化處理」節點,確保能正確取得新的資料結構:

// 從上游節點取得完整會議資料和 Notion 頁面資料
const fullMeetingData = $('提取結構化任務').first().json;
const notionPageData = $input.first().json;

let summary, tasks, notionUrl;

// 從完整會議資料中提取摘要,優先使用 meeting_info
if (fullMeetingData.meeting_info && fullMeetingData.meeting_info.summary) {
  summary = fullMeetingData.meeting_info.summary;
} else if (fullMeetingData.meeting_attributes && fullMeetingData.meeting_attributes.summary) {
  summary = fullMeetingData.meeting_attributes.summary;
} else {
  summary = "無法取得會議摘要";
}

// 從完整會議資料中提取任務清單
if (fullMeetingData.meeting_info && fullMeetingData.meeting_info.tasks) {
  tasks = fullMeetingData.meeting_info.tasks;
} else {
  tasks = "無法取得行動任務";
}

// 取得 Notion 頁面連結
notionUrl = notionPageData.url || "https://notion.so";

// 智能 Markdown 轉純文字函式
function markdownToPlainText(markdown) {
  if (!markdown || typeof markdown !== 'string') {
    return '';
  }
  
  // 智能檢測:如果是格式化任務清單,保留原格式
  const isFormattedTasks = markdown.includes('任務 ') && markdown.includes('• 負責人:');
  
  if (isFormattedTasks) {
    // 對格式化任務只做最基本的清理,保留換行結構
    return markdown
      .replace(/``````/gs, '')           // 移除程式碼區塊
      .replace(/`([^`]+)`/g, '$1')          // 移除內聯程式碼
      .replace(/(\*\*|__)(.*?)\1/g, '$2')   // 移除粗體
      .replace(/(\*|_)(.*?)\1/g, '$2')      // 移除斜體
      .replace(/\n{3,}/g, '\n\n')           // 只清理過多的連續換行
      .trim();
  }
  
  // 一般內容進行完整的 Markdown 清理
  let text = markdown;
  text = text.replace(/^#{1,6}\s+/gm, '');
  text = text.replace(/(\*\*|__)(.*?)\1/g, '$2');
  text = text.replace(/(\*|_)(.*?)\1/g, '$2'); 
  text = text.replace(/~~(.*?)~~/g, '$1');
  text = text.replace(/!\[.*?\]\(.*?\)/g, '');
  text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1');
  text = text.replace(/^\s*[*\-\+]\s+/gm, '• ');
  text = text.replace(/^\s*\d+\.\s+/gm, '');
  text = text.replace(/^---+\s*$/gm, '');      
  text = text.replace(/^___+\s*$/gm, '');      
  text = text.replace(/^\*\*\*+\s*$/gm, '');   
  text = text.replace(/``````/gs, '');
  text = text.replace(/`([^`]+)`/g, '$1');
  text = text.replace(/\n{3,}/g, '\n\n');
  text = text.replace(/\n/g, ' ');  // 只對一般內容把換行變空格
  
  return text.trim();
}

// 將 Markdown 格式轉換為 LINE 訊息適用的純文字
const plainSummary = markdownToPlainText(summary);
const plainTasks = markdownToPlainText(tasks);  // 會自動保留格式化任務的換行

// 取得專案名稱,優先使用會議屬性中的專案名稱
let projectName = "AI會議摘要";
if (fullMeetingData.meeting_attributes && fullMeetingData.meeting_attributes.project_name) {
  projectName = fullMeetingData.meeting_attributes.project_name;
} else if (notionPageData.name) {
  projectName = notionPageData.name;
}

// 組合 LINE 訊息內容
const lineMessageText = `🎯 會議處理完成通知

- 專案:${projectName}
- 日期:${new Date().toLocaleDateString('zh-TW')}
- Notion 頁面:${notionUrl}

--- 會議摘要 ---
${plainSummary}

--- 行動任務 ---
${plainTasks}`;

// 建立 LINE API 的負載格式
const lineApiPayload = {
  "messages": [
    {
      "type": "text",
      "text": lineMessageText
    }
  ]
};

// 回傳處理完成的資料
return [{
  json: {
    summary: summary,
    tasks: tasks,
    url: notionUrl,
    lineApiPayload: lineApiPayload,
    projectName: projectName
  }
}]; 

Step 5:測試與驗證

完成架構重構後,需要進行測試。

5-1 準備測試資料

使用包含豐富資訊的會議音訊檔進行測試

  • 明確的專案討論
  • 多位參與者互動
  • 具體的任務分配
  • 時間期限描述

5-2 執行完整流程測試

  1. 開啟 Gradio 前端介面
  2. 上傳測試音訊檔案
  3. 在使用者指令中輸入:請生成會議摘要與提取行動任務
  4. 點擊「開始處理」

5-3 最終結果

確認 Notion「會議記錄」中的資料

  • 會議基本資訊內容正確
  • 參與者標記正確
  • 任務指派判斷正確
  • Line 通知發送正常

Gradio 前端頁面
Gradio

LINE 通知
Line

Notion
Notion

n8n workflow
WorkFlow


今天的成果總結

完成項目

  • 成功整合並強化了三個核心 AI 任務鏈節點,實現了真正的模組化智慧處理流程
  • 完成了端到端的測試,驗證系統在各種條件下的穩定性與可靠性
  • 達成了預設目標:摘要、參與者、任務、指派、期限等核心項目的準確率超過 90%
  • 最佳化了所有相關節點的資料流與表達式,確保整體工作流程的順暢運作
  • 完善了容錯機制與錯誤處理邏輯,大幅提升了系統的強健性與可用性
  • Notion 相關欄位成功紀錄與完成了 LINE 通知系統

心得

今天是 M2A Agent 開發歷程中最具里程碑意義的一天,透過多場景測試設計各種邊界條件,我不僅發現並修復了許多潛在問題。

在處理複雜多任務會議時能準確解析每個獨立期限,這讓我建立了對系統可靠性的信心,這種成就感是難以言喻的。

經過前面幾天的架構重構與功能精煉,今天我終於將所有分散的成果整合成一個完整、穩定、高效的智慧系統。

🎯 明天計劃

設定 Gmail API 認證,實作 HTML 格式郵件通知模組,擴展現有通知機制。


上一篇
Day 17 任務鏈精煉 — 精準配對與獨立期限解析
下一篇
Day 19 整合 Gmail — 自動化任務指派通知
系列文
打造基於 MCP 協議與 n8n 工作流的會議處理 Agent21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言